package com.ztesoft.book.admin.config.shiro.jwt;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.auth0.jwt.exceptions.SignatureVerificationException;
import com.google.common.collect.Maps;
import com.ztesoft.book.common.constant.AuthorizationThrow;
import com.ztesoft.book.common.constant.Constant;
import com.ztesoft.book.common.utils.Result;
import com.ztesoft.book.common.utils.exception.ExUtil;
import com.ztesoft.book.common.utils.jwt.JwtUtil;
import com.ztesoft.book.common.utils.redis.RedisUtils;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.web.filter.authc.BasicHttpAuthenticationFilter;
import org.apache.shiro.web.util.WebUtils;
import org.owasp.encoder.Encode;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.RequestMethod;

import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Map;

/**
 * @Description 名称为 JWT 的自定义过滤器
 * @Author YangFan
 * @Date 2021-06-22 16:46
 */
@Slf4j
@Component
public class JwtFilter extends BasicHttpAuthenticationFilter {

    protected static final String AUTHORIZATION_HEADER = "token";

    private static String redisTokenExpireTime;

    @Value("${book.time.redis-token-expire}")
    public void setRedisTokenExpireTime(String redisTokenExpireTime) {
        setRedisTokenExpireTimeValue(redisTokenExpireTime);
    }

    public static void setRedisTokenExpireTimeValue(String redisTokenExpireTime) {
        JwtFilter.redisTokenExpireTime = redisTokenExpireTime;
    }


    /**
     * 这里我们详细说明下为什么最终返回的都是true，即允许访问（已配置关闭）
     * 例如我们提供一个地址 GET /article
     * 登入用户和游客看到的内容是不同的
     * 如果在这里返回了false，请求会被直接拦截，用户看不到任何东西
     * 所以我们在这里返回true，Controller中可以通过 subject.isAuthenticated() 来判断用户是否登入
     * 如果有些资源只有登入用户才能访问，我们只需要在方法上面加上 @RequiresAuthentication 注解即可
     * 但是这样做有一个缺点，就是不能够对GET,POST等请求进行分别过滤鉴权(因为我们重写了官方的方法)，但实际上对应用影响不大
     *
     * @param request     【参数 1 注释说明】
     * @param response    【参数 2 注释说明】
     * @param mappedValue 【参数 3 注释说明】
     * @return boolean                        【返回值 注释说明】
     * @author YangFan
     * @date 2021-06-22 16:50
     */
    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
        // 查看当前Header中是否携带Authorization属性(Token)，有的话就进行登录认证授权
        if (this.isLoginAttempt(request, response)) {
            try {
                // 进行Shiro的登录UserRealm
                this.executeLogin(request, response);
            }
            catch (Exception e) {
                // 认证出现异常，传递错误信息msg
                String msg = e.getMessage();
                // 获取应用异常(该Cause是导致抛出此throwable(异常)的throwable(异常))
                Throwable throwable = e.getCause();
                if (throwable instanceof SignatureVerificationException) {
                    // 该异常为JWT的AccessToken认证失败(Token或者密钥不正确)
                    msg = "Token或者密钥不正确(" + throwable.getMessage() + ")";
                }
                else {
                    // 应用异常不为空
                    if (throwable != null) {
                        // 获取应用异常msg
                        msg = throwable.getMessage();
                    }


                    if (AuthorizationThrow.JWT_TOKEN_EXPIRE.equals(msg)) {
                        // 如果是抛出的处理后的JWT_TOKEN过期异常，则需要重新登录（当前JWT_TOKEN半永久处理，有效期一天）
                        msg = "JWT_TOKEN已过期，请重新登录！";
                    }
                    else if (AuthorizationThrow.REDIS_TOKEN_NEED_REFRESH.equals(msg)) {
                        // REDIS_TOKEN有效期将至，进行续期
                        if (this.refreshToken(request, response)) {
                            return true;
                        }
                        else {
                            msg = "REDIS_TOKEN续期失败！";
                        }
                    }
                    else if (AuthorizationThrow.JWT_TOKEN_EXPLAIN_ERROR.equals(msg)) {
                        // JWT_TOKEN格式不正确解析错误
                        msg = "JWT_TOKEN格式不正确！";
                    }

                }
                // Token认证失败直接返回Response信息
                this.response401(response, msg);
                return false;
            }
        }
        else {
            // 没有携带Token
            HttpServletRequest httpServletRequest = WebUtils.toHttp(request);
            // 获取当前请求类型
            String httpMethod = httpServletRequest.getMethod();
            // 获取当前请求URI
            String requestUri = httpServletRequest.getRequestURI();
            log.info("当前请求 {} Authorization属性(Token)为空 请求类型 {}", replaceCRLFWithUnderscore(requestUri), replaceCRLFWithUnderscore(httpMethod));
            // mustLoginFlag = true 开启任何请求必须登录才可访问
            final Boolean mustLoginFlag = true;
            if (mustLoginFlag) {
                this.response401(response, "请先登录");
                return false;
            }
        }
        return true;
    }


    /**
     * 这里我们详细说明下为什么重写
     * 可以对比父类方法，只是将executeLogin方法调用去除了
     * 如果没有去除将会循环调用doGetAuthenticationInfo方法
     *
     * @param request  【参数 1 注释说明】
     * @param response 【参数 2 注释说明】
     * @return boolean                        【返回值 注释说明】
     * @author YangFan
     * @date 2021-06-22 16:49
     */
    @Override
    protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
        HttpServletResponse httpServletResponse = (HttpServletResponse) response;
        httpServletResponse.setCharacterEncoding("UTF-8");
        httpServletResponse.setHeader("Access-Control-Allow-Origin", "*");
        httpServletResponse.setHeader("Content-type", "application/json;charset=UTF-8");
        try {
            response.getWriter().write(Encode.forHtml(JSON.toJSONString(Result.error("登录过期"))));
        }
        catch (IOException ex) {
            log.error(ex.getMessage());
        }
        return false;
        // this.sendChallenge(request, response);
        // return false;
    }


    /**
     * 方法功能描述:
     * < 检测Header里面是否包含Authorization字段，有就进行Token登录认证授权 >
     *
     * @param request  【参数 1 注释说明】
     * @param response 【参数 2 注释说明】
     * @return boolean                        【返回值 注释说明】
     * @author YangFan
     * @date 2021-06-22 16:49
     */
    @Override
    protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) {
        // 拿到当前Header中Authorization的AccessToken(Shiro中getAuthzHeader方法已经实现)
        String token = this.getAuthzHeader(request);
        return token != null;
    }


    /**
     * 方法功能描述:
     * < 进行AccessToken登录认证授权 >
     *
     * @param request  【参数 1 注释说明】
     * @param response 【参数 2 注释说明】
     * @return boolean                        【返回值 注释说明】
     * @author YangFan
     * @date 2021-06-22 16:49
     */
    @Override
    protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
        // 拿到当前Header中Authorization的AccessToken(Shiro中getAuthzHeader方法已经实现)
        JwtToken token = new JwtToken(this.getAuthzHeader(request));
        // 提交给UserRealm进行认证，如果错误他会抛出异常并被捕获
        this.getSubject(request, response).login(token);
        // 如果没有抛出异常则代表登入成功，返回true
        return true;
    }


    /**
     * 方法功能描述:
     * < 对跨域提供支持 >
     *
     * @param request  【参数 1 注释说明】
     * @param response 【参数 2 注释说明】
     * @return boolean                        【返回值 注释说明】
     * @author YangFan
     * @date 2021-06-22 16:49
     */
    @Override
    protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {

        HttpServletRequest httpRequest = WebUtils.toHttp(request);
        HttpServletResponse httpResponse = WebUtils.toHttp(response);
        if (httpRequest.getMethod().equals(RequestMethod.OPTIONS.name())) {

            //HRS_REQUEST_PARAMETER_TO_HTTP_HEADER
            String origin = httpRequest.getHeader("Origin");
            if (origin != null) {
                String originHeader = urlEncoder(origin);
                httpResponse.setHeader("Access-Control-Allow-Origin", originHeader);
            }

            String method = httpRequest.getMethod();
            if (method != null) {
                String methodHeader = urlEncoder(method);
                httpResponse.setHeader("Access-Control-Allow-Methods", methodHeader);
            }

            String requestH = httpRequest.getHeader("Access-Control-Request-Headers");
            if (requestH != null) {
                String requestHeader = urlEncoder(requestH);
                httpResponse.setHeader("Access-Control-Allow-Headers", requestHeader);
            }

            httpResponse.setStatus(HttpStatus.OK.value());
            return false;
        }

        return super.preHandle(request, response);
    }


    /**
     * 方法功能描述:
     * < 此处为RedisToken刷新，修改了Token刷新机制，这里不再刷新JWT，而是刷新REDIS中的Token >
     *
     * @param request  【参数 1 注释说明】
     * @param response 【参数 2 注释说明】
     * @return boolean                        【返回值 注释说明】
     * @author YangFan
     * @date 2021-06-22 16:49
     */
    private boolean refreshToken(ServletRequest request, ServletResponse response) {
        // 拿到当前Header中Authorization的AccessToken(Shiro中getAuthzHeader方法已经实现)
        String token = this.getAuthzHeader(request);
        // 获取当前Token的帐号信息
        String username = JwtUtil.getClaim(token, Constant.USERNAME);
        // 判断Redis中RefreshToken是否存在
        if (RedisUtils.exists(Constant.PREFIX_SHIRO_REFRESH_TOKEN + username)) {
            // Redis中RefreshToken还存在，获取RefreshToken的时间戳
            Map<String, Object> redisToken = RedisUtils.get(Constant.PREFIX_SHIRO_REFRESH_TOKEN + username, Maps.newHashMap().getClass());
            String currentTimeMillisRedis = redisToken.get("currentTimeMillis").toString();
            // 获取AccessToken时间戳，与RefreshToken的时间戳对比
            boolean b = true;

            // 这里校验时间戳则可以保证单点登录，不校验则同账号可以多处同时登录
            if (!JwtUtil.getClaim(token, Constant.CURRENT_TIME_MILLIS).equals(currentTimeMillisRedis)) {
                b = false;
            }

            if (b) {
                // 刷新RedisToken
                RedisUtils.resetExpire(Constant.PREFIX_SHIRO_REFRESH_TOKEN + username, Long.parseLong(redisTokenExpireTime));
                // 将新刷新的AccessToken再次进行Shiro的登录
                JwtToken jwtToken = new JwtToken(token);
                // 提交给UserRealm进行认证，如果错误他会抛出异常并被捕获，如果没有抛出异常则代表登入成功，返回true
                this.getSubject(request, response).login(jwtToken);
                return true;
            }
        }
        return false;
    }


    /**
     * 方法功能描述:
     * < 无需转发，直接返回Response信息 >
     *
     * @param response 【参数 1 注释说明】
     * @param msg      【参数 2 注释说明】
     * @return void                            【返回值 注释说明】
     * @author YangFan
     * @date 2021-06-22 16:50
     */
    private void response401(ServletResponse response, String msg) {
        HttpServletResponse httpServletResponse = WebUtils.toHttp(response);
        httpServletResponse.setStatus(HttpStatus.UNAUTHORIZED.value());
        httpServletResponse.setCharacterEncoding("UTF-8");
        httpServletResponse.setContentType("application/json; charset=utf-8");
        try (PrintWriter out = httpServletResponse.getWriter()) {
            String data = JSONObject.toJSONString(Result.error(HttpStatus.UNAUTHORIZED.value(), "无权访问(Unauthorized):" + msg));

            //XSS_SERVLET: Potential XSS in Servlet
            out.write(Encode.forHtml(data));
        }
        catch (IOException e) {
            log.error("直接返回Response信息出现IOException异常", e);
            ExUtil.throwEx("直接返回Response信息出现IOException异常:" + e.getMessage());
        }
    }


    /**
     * 方法功能描述:
     * < 重写获取token方法，与党建项目保持一致 >
     *
     * @param request 【参数 1 注释说明】
     * @return String                        【返回值 注释说明】
     * @author YangFan
     * @date 2021-06-29 18:03
     */
    @Override
    protected String getAuthzHeader(ServletRequest request) {
        HttpServletRequest httpRequest = WebUtils.toHttp(request);
        String token = httpRequest.getHeader(AUTHORIZATION_HEADER);
        if (StringUtils.isBlank(token)) {
            token = httpRequest.getParameter("token");
        }
        return token;
    }


    private String urlEncoder(String header) throws UnsupportedEncodingException {
        return URLEncoder.encode(header, StandardCharsets.UTF_8.displayName());
    }

    public static String replaceCRLFWithUnderscore(String value) {
        return value.replace('\n', '_').replace('\r', '_');
    }

}
